這一章, 我們先來實作基本的寵物互動功能,
摸一摸 Cursor 寫代碼的工作流程, 試探一下 Cursor 的能力
在開始之前, 我們先用 Cursor 打開這個專案
然後在設定的頁面先給 Cursor 加上一些 Unity 寫代碼的規則
https://cursor.com/zh/docs/context/rules#project-rules
Cursor 的 Project Rules 是一種可以在這個專案下持續遵守的規則
其實就是一段每次對話的時候, 都會先塞進去的預製 Prompt
我們先來設定一些寫 Unity 代碼的規則:
---
description: |
Unity C# 腳本開發規範與最佳實踐。用於指導 Cursor 在生成、修改
`Assets/**/*.cs` 內的 Unity 腳本時,保持一致的風格與高可讀性,避免常見
性能/架構問題。
globs:
- Assets/**/*.cs
- Packages/**/*.cs
alwaysApply: true
---
## 目標
- 保持代碼乾淨簡單、容易使用、清晰、可維護、可擴展。
- 避免不必要的 GC 分配與每幀開銷。
- 使用一致的命名與生命週期方法順序。
- 盡量把可以控制的變數開放在 Inspector 中進行設定。
- Function 上方加上直覺容易理解的註解
### 命名規範
- 類名/結構名:PascalCase,如 `PlayerController`。
- 方法名:PascalCase,如 `StartMovement()`。
- 變量/欄位名:camelCase,如 `moveSpeed`。常量使用 `PascalCase` 或 `UPPER_SNAKE_CASE`。
- 序列化欄位:加 `[SerializeField] private`,公開 API 再用 `public`。
- 事件:使用名詞+動詞,`OnXxxHappened`;委託型欄位以 `Handler` 或 `Action` 結尾。
### 文件與類
- 一個 `.cs` 文件僅包含一個公共類。文件名與公共類名一致。
- 避免在同檔中放置多個 MonoBehaviour。
- 在 `Awake` 做引用快取、資料初始化;避免昂貴操作。
- 在 `Start` 依賴其他物件初始化完成後再做邏輯。
- 物理邏輯放 `FixedUpdate`;跟隨攝影機或排序放 `LateUpdate`。
- 使用 `OnEnable/OnDisable` 註冊/反註冊事件,避免漏記憶體或重複註冊。
### 序列化與 Inspector
- 盡量使用 `[SerializeField] private` 暴露到 Inspector,保持封裝性。
- 使用 `[Header]`, `[Tooltip]`, `[Range]` 改善可用性。
- 頻繁查找的組件在 `Awake` 快取,如 `rigidbody2D`, `spriteRenderer`。
### Update/協程/事件
- 減少 `Update` 中的分配與反覆查找;若非每幀需要,改用事件或計時器。
- 大量定時邏輯用協程或 `InvokeRepeating`;但注意在 `OnDisable` 停止。
- 使用 `Time.deltaTime` 處理幀率無關邏輯。
### 物理
- 物理運算放在 `FixedUpdate`,並使用 `Time.fixedDeltaTime`。
- 僅在必要時使用 `Rigidbody` 與碰撞回調;避免在 `Update` 手動移動剛體。
### 資源與記憶體
- 避免每幀產生臨時字串/Boxing;重用 `StringBuilder` 或快取陣列/列表。
- 使用 `ObjectPool` 重用頻繁生成/銷毀的物件。
- 使用 `using` 或顯式釋放非受管資源。
### 日誌與錯誤處理
- 開發期可用 `Debug.Log/Warning/Error`;生產需集中管理與可關閉。
- 避免在熱路徑頻繁 `Log`;必要時加開關。
### 依賴與測試性
- 盡量依賴抽象(介面)而非具體實作,便於替換與測試。
- 使用組合優於繼承;避免深層繼承樹。
### 註釋與文件
- 寫「為什麼」而非「怎麼做」。對複雜流程添加方法註解或 XML doc。
- 不要解釋顯而易見的代碼;保持代碼自註解化。
### 格式化
- 與現有風格一致;長參數列表分行;避免過深巢狀。
- 早返回簡化條件;適度拆分長方法(>80-100 行)。
### 常見反模式(避免)
- 在 `Update` 內 `FindObjectOfType`/`GetComponent`。
- 無節制的 `new` 或 string 拼接造成 GC。
- 公開可寫欄位導致狀態不可控。
- 在 `OnDestroy` 才解註冊事件(可能不會被調用),改用 `OnDisable`。
### 檔案/資料夾結構建議
- Editor 專用腳本放 `Editor/` 並使用 `#if UNITY_EDITOR` 條件編譯。
### 範例片段(原則示意)
- 使用 `[SerializeField] private` + 屬性只讀公開。
- 在 `Awake` 快取引用;在 `OnEnable/OnDisable` 註冊/解註冊事件。
- 物理相關移動放 `FixedUpdate`。
你可以直接複製貼上, 簡單來說就是規範 Cursor 在生成代碼的時候
盡量在 Inspector 中架設好可以控制的變量, 並且寫好註解
代碼要簡單, 乾淨, 性能效率要好, 可以方便擴展和維護
接下來我們可以再開一個規則介紹遊戲的背景資訊:
---
description: 這款遊戲的開發背景資訊
globs:
alwaysApply: true
---
名字: Polar Pet
介紹:
- 一款 2D 的寵物互動遊戲, 可以拖拽寵物, 使用道具跟寵物進行互動
- 擁有 AI 的功能, 可以呼叫 API 進行對話互動
- 寵物和道具使用 Sprite Renderer
- 有一個 UI 可以拖放道具到場景中進行互動
根據我的指示慢慢的把功能實現
當然, 你可以自行替換成你正在開發的遊戲背景設定。
那麼接下來,我們就來實際開發一個可以拖拽寵物的功能試試看:
Prompt:
實作可以在場景中滑鼠點擊後拖拽寵物的功能:
1. 點擊拖拽後要切換動畫 - Drag
2. 放手後寵物恢復成原本的動畫 - Walk_bear
3. 滑鼠拖動的時候, 左和右移動時要Flip反轉圖片的X值
4. 拖拽的時候, 要在下方顯示陰影的效果
Code:
using UnityEngine;
/// <summary>
/// 控制寵物在場景中被滑鼠拖拽的行為:
/// 1. 按下滑鼠並拖拽時切換動畫為 Drag。
/// 2. 放開滑鼠時恢復動畫為 Walk_bear。
/// 3. 拖拽移動時依照左右方向 Flip X。
/// 4. 拖拽時顯示陰影,放開時隱藏陰影。
///
/// 設計原則:
/// - 盡量使用序列化欄位以便從 Inspector 設定。
/// - 在 Awake 快取常用組件,避免每幀查找。
/// - 不依賴 Update 輪詢,僅在拖拽狀態時更新。
/// </summary>
[RequireComponent(typeof(Collider2D))]
public class PetDragController : MonoBehaviour
{
[Header("References")]
[Tooltip("Animator,用於切換 Drag / Walk_bear 動畫")]
[SerializeField] private Animator animator;
[Tooltip("主要 Sprite 用於 FlipX,若為 SpriteRenderer 放這裡;若是整個物件旋轉,改為 Transform 對象。")]
[SerializeField] private SpriteRenderer spriteRenderer;
[Tooltip("陰影 GameObject(例如一個半透明橢圓),拖拽時顯示")]
[SerializeField] private GameObject shadowObject;
[Header("Animation Names")]
[Tooltip("拖拽時播放的動畫狀態名稱")]
[SerializeField] private string dragStateName = "Drag";
[Tooltip("放手後恢復的動畫狀態名稱")]
[SerializeField] private string idleStateName = "Walk_bear";
[Header("Drag Settings")]
[Tooltip("拖拽時的 Z 軸世界座標(確保在合適排序層級)")]
[SerializeField] private float dragWorldZ = 0f;
[Tooltip("將螢幕滑鼠位置轉世界座標時的相機,若留空則在 Awake 取得主相機")]
[SerializeField] private Camera targetCamera;
[Tooltip("是否限制拖拽移動速度(避免瞬間跳動)。0 或負值表示不限制")]
[SerializeField] private float maxDragSpeed = 0f;
[Tooltip("Flip 方向判定的最小位移閾值,避免 微小抖動 觸發 Flip")]
[SerializeField] private float flipMinDelta = 0.001f;
private bool isDragging;
private Vector3 dragOffsetWorld;
private Vector3 lastWorldPos;
/// <summary>
/// 快取相機與必要組件
/// </summary>
private void Awake()
{
if (targetCamera == null)
{
targetCamera = Camera.main;
}
if (animator == null)
{
animator = GetComponentInChildren<Animator>();
}
if (spriteRenderer == null)
{
spriteRenderer = GetComponentInChildren<SpriteRenderer>();
}
if (shadowObject != null)
{
shadowObject.SetActive(false);
}
else if (spriteRenderer != null)
{
var shadowGO = new GameObject("Shadow");
shadowGO.transform.SetParent(transform, false);
shadowGO.transform.localPosition = new Vector3(0f, -0.6f, 0f);
shadowGO.transform.localScale = new Vector3(1.0f, 0.35f, 1f);
var shadowSr = shadowGO.AddComponent<SpriteRenderer>();
shadowSr.sprite = spriteRenderer.sprite;
shadowSr.color = new Color(0f, 0f, 0f, 0.25f);
shadowSr.sortingLayerID = spriteRenderer.sortingLayerID;
shadowSr.sortingOrder = spriteRenderer.sortingOrder - 1;
shadowObject = shadowGO;
shadowObject.SetActive(false);
}
}
/// <summary>
/// 滑鼠按下時進入拖拽狀態
/// </summary>
private void OnMouseDown()
{
if (targetCamera == null) return;
isDragging = true;
Vector3 mouseWorld = GetMouseWorldPosition();
dragOffsetWorld = transform.position - mouseWorld;
lastWorldPos = transform.position;
PlayAnimation(dragStateName);
SetShadowActive(true);
}
/// <summary>
/// 滑鼠釋放時離開拖拽狀態
/// </summary>
private void OnMouseUp()
{
isDragging = false;
PlayAnimation(idleStateName);
SetShadowActive(false);
}
/// <summary>
/// 拖拽中更新位置與 Flip
/// </summary>
private void Update()
{
if (!isDragging) return;
if (targetCamera == null) return;
// 若拖拽中,但滑鼠在物件外放開,也要正確結束拖拽
if (Input.GetMouseButtonUp(0))
{
isDragging = false;
PlayAnimation(idleStateName);
SetShadowActive(false);
return;
}
Vector3 mouseWorld = GetMouseWorldPosition();
Vector3 targetPos = mouseWorld + dragOffsetWorld;
targetPos.z = dragWorldZ;
if (maxDragSpeed > 0f)
{
float maxDelta = maxDragSpeed * Time.deltaTime;
transform.position = Vector3.MoveTowards(transform.position, targetPos, maxDelta);
}
else
{
transform.position = targetPos;
}
Vector3 delta = transform.position - lastWorldPos;
if (Mathf.Abs(delta.x) > flipMinDelta)
{
SetFlipX(delta.x < 0f);
}
lastWorldPos = transform.position;
}
/// <summary>
/// 依名稱切換 Animator 狀態
/// </summary>
private void PlayAnimation(string stateName)
{
if (animator == null || string.IsNullOrEmpty(stateName)) return;
animator.Play(stateName);
}
/// <summary>
/// 控制陰影顯示
/// </summary>
private void SetShadowActive(bool active)
{
if (shadowObject == null) return;
if (shadowObject.activeSelf == active) return;
shadowObject.SetActive(active);
}
/// <summary>
/// 設定 SpriteRenderer 的 FlipX
/// </summary>
private void SetFlipX(bool flip)
{
if (spriteRenderer == null) return;
spriteRenderer.flipX = flip;
}
/// <summary>
/// 從滑鼠螢幕座標換算世界座標
/// </summary>
private Vector3 GetMouseWorldPosition()
{
Vector3 screenPos = Input.mousePosition;
screenPos.z = Mathf.Abs(targetCamera.transform.position.z - transform.position.z);
Vector3 world = targetCamera.ScreenToWorldPoint(screenPos);
world.z = dragWorldZ;
return world;
}
private void OnDisable()
{
if (isDragging)
{
isDragging = false;
SetShadowActive(false);
PlayAnimation(idleStateName);
}
}
}
上面是我使用的Prompt和生成的代碼,
操作的時候建議可以把 Unity MCP 的功能先關掉,
它才不會一直想要去嘗試自己 Add Component 到寵物上, 花費很多時間
代碼生成好之後, 我們可以手動在場景多增加一個 Shadow 的陰影物件
然後把代碼放到北極熊身上, 並把對應的物件放進去
記得也要在北極熊身上架 Box Collider 2D 的 Component
恭喜你, 這時候可以嘗試播放一下遊戲
一切順利的話, 你就可以得到一個可以拖拽的北極熊了 !
Note:
如果 OnMouseClick / On Mouse Drag 無法使用
可以到 Project Setting/Player, 把 Active Input Handling 設定成 Both
在下一章, 我們來繼續實作道具的功能
讓我們的北極熊寵物可以吃東西和搓澡